fix(orchestrator): bound the whole sweep, not one more call inside it - #374
Conversation
|
@coderabbitai review Requested for exact head |
|
Warning Review limit reachedNext included review available in 48 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe PR adds configurable aggregate budgets for discovery sweeps. It applies shared deadlines across sweep phases, bounds teardown, compensates delayed lease claims, prevents stale writes, and adds unit and end-to-end coverage. ChangesDiscovery sweep budgeting
Estimated code review effort: 4 (Complex) | ~60 minutes Merge Risk: ⚪ Minimal · up to The PR bounds the full sweep and teardown without an identified call-site contract issue; no actionable merge-blocking risk remains beyond normal checks and review. Sequence Diagram(s)sequenceDiagram
participant Factory
participant SweepBudget
participant StateStore
participant DiscoverySession
Factory->>SweepBudget: start aggregate sweep budget
Factory->>StateStore: claim discovery lease
Factory->>DiscoverySession: prepare and execute discovery
DiscoverySession-->>Factory: return discovery results
Factory->>StateStore: checkpoint and commit results
SweepBudget-->>Factory: signal expiry
Factory->>StateStore: release or compensate lease
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 4 files. (2 skipped: 2 too large.) ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Three unbounded calls have wedged this sweep in a single day, on three different transports. Each was real, each was bounded, and each time the wedge came back one layer down: the FACTORY_STATE Durable Object calls (factory-cloud#78), the relayfile change-feed tail reads (#368, shipped and verified in 0.1.75), then the retry of the now-bounded call. This does not bound a fourth. It makes the class of failure survivable. The property it establishes: NO SWEEP CAN BE IN FLIGHT FOR LONGER THAN ITS BUDGET, whatever it is waiting on. Elapsed time is charged against ONE timer for the whole pass, so it does not matter which await is slow, how many there are, or how many times the sweep retries one of them. The next unbounded call degrades a sweep instead of ending dispatch. WHY A PER-CALL BOUND CANNOT DO THIS. `relayfileOperationTimeoutMs` bounds one relayfile call and cannot see the retry loop around it or a call on another transport. `reconcileTimeoutMs` bounds the CALLER'S WAIT from outside `runOnce()`, so expiry leaves the sweep running and every later cycle coalesces onto the same wedged promise (factory.ts `runOnce()`, the `#runOnceInFlight` branch) — which is why the deployed daemon never recovers. The budget expires from INSIDE `#runOnceWithDiscoveryFence`, so the sweep unwinds, the lease goes back, `#runOnceInFlight` clears, and the next cycle claims a fresh lease. MECHANISM, PLAINLY. `budget.run()` is a race, not a cancellation — the same limitation #368 documented, stated for the same reason. CAN: abandon an in-flight await, from any transport, and unwind the sweep. CANNOT: stop the abandoned work. The socket stays open, the SDK's own retry loop keeps running, and a side effect already in flight still lands. PARTIAL: `budget.signal` aborts at expiry, so anything honouring an AbortSignal is really cancelled — nothing in the sweep consumes it yet (the relayfile client mints its own per-call signal and that file is owned by another lane this week); it is exported so wiring it is one line. `assertNotExpired()` is a between-await check and is worth nothing against a call that never returns, but it does make an abandoned pass unwind at its next loop iteration rather than run to completion beside its replacement. TEARDOWN IS BOUNDED SEPARATELY. On the path that matters the budget is spent by construction, so teardown cannot run under it or the lease would never be released — and releasing it is the half that makes the next cycle clean. An unbounded release would re-create this wedge one layer down. It gets a 30 s deadline; an abandoned release costs an orphaned lease for one expiry window, which a later sweep reclaims (`claim.reclaimedLease`). DEFAULT IS THE EXISTING ENVELOPE, DELIBERATELY. `sweepBudgetMs` defaults to `reconcileTimeoutMs` (90 min) and is clamped to it, so no sweep that survives today is killed by this. The value is a policy dial, the mechanism is the fix. Tightening it has a real cost: the checkpoint commits only at the end, so a budget below realistic cold-mirror hydration (#36 measured 61 min in production) makes a slow boot a loop that never progresses. TESTS (11), must-fire/must-not-fire for each: - must-fire, end to end: a sweep whose first post-claim call never returns is aborted at its budget naming the phase, the lease release is OBSERVED on the store, and the next cycle runs a fresh sweep and dispatches. Fail-first verified by mechanism: with only factory.ts reverted it fails after 4038 ms with "sweep never settled" — the pass never settles, exactly as production. - must-fire, primitive: three 40 ms calls under a 120 ms budget — the third is rejected because the SWEEP is out of time, not because it is slow; a bounded-but-always-failing call inside an unbounded retry loop ends at the budget (the L3 shape) after more than one attempt; the signal aborts; a spent budget refuses to start new work against the dependency it gave up on. - must-not-fire: a healthy sweep under a snug 30 s budget produces results IDENTICAL to an unbounded control (pulled, dispatched, skipped, spawns) — without this the trivial wrong fix, abort everything, passes; a caller's own failure still surfaces as itself and is never re-clothed as a budget expiry; with `sweepBudgetMs: 0` the same hung call stays pending, so every rejection above is attributable to the budget and not to the wrapper. WHAT THIS DOES NOT COVER. - It does not make anything faster or find the hanging call. A wedged dependency still costs one whole budget per cycle. - It does not cancel. See MECHANISM above. - The abandoned pass runs concurrently with the sweep that replaces it if it ever unsticks. It cannot commit a checkpoint (the store's epoch guard) but its in-flight side effects still land. - Two `stop()`/shutdown paths and the `#runOnceWithReadinessDeadline` abandoned-wait bookkeeping are unchanged; a budget expiry reaches them as an ordinary sweep failure. - The default changes no timing. Recovery inside 90 minutes needs either a tighter `sweepBudgetMs` or the L3 retry bound the other lane owns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2 Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
2e31da9 to
b032e85
Compare
|
@coderabbitai review Requested for exact head |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2e31da9a28
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Review completed against the latest diff
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…dget backstop The seven `bounded readiness reconciliation` cases assert on a sweep that the readiness deadline abandoned and that is STILL RUNNING. The aggregate budget makes that state unreachable at its default — it aborts the sweep at or before that deadline, so there is nothing left in flight to observe. That is the fix, not a regression. Each now passes `sweepBudgetMs: 0`, which selects the pre-#372 backstop those assertions are actually about: the #296/#301 abandoned-wait accounting, still the behaviour when the budget is disabled and still the shape a sweep degrades to if a teardown path cannot be abandoned. `0` as the disable value is the same control idiom #368 used for `operationTimeoutMs`. Adds the positive counterpart, which the redirected cases can no longer state: a live daemon whose first post-claim call never returns still completes `start()` and `stop()`, because the sweep is aborted rather than abandoned. Fail-first verified by mechanism against `origin/main`'s factory.ts: it fails after 4044 ms with `start never returned`. That is deliverable B demonstrated rather than argued — `#deferLiveEventDrain = false` sits in a `finally` around that unbounded `runOnce()` (main factory.ts:1983/2021/2039), so a wedged startup backfill also kills the live-event dispatch path, which is why a hung sweep meant zero dispatch instead of stale dispatch. Also documents a gap the shutdown test exposed and this PR does NOT close: `#startLiveSubscription` reads the event high-watermark before the backfill and outside any sweep, so that read is bounded only by the per-call relayfile deadline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
|
@coderabbitai review Requested for exact head |
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…abandoned pass Five findings, all valid at 5c656f9, each with its own must-fire/must-not-fire. 1. THE DANGEROUS ONE. A fixed 90-minute `sweepBudgetMs` default is ABOVE any config that had already tightened `reconcileTimeoutMs`, so the cross-field check rejected it and `FactoryConfigSchema.parse` threw — Factory would not have started. It also silently capped a config that loosened the timeout above 90 minutes. The omitted budget is now derived from its SIBLING in a `.transform()`, never from a constant, and `resolvedSweepBudgetMs` is the one rule the schema and the orchestrator's `start()` clamp both use. 2. An abandoned `#performRunOnce` could write a stale tree listing into the REPLACEMENT sweep's checkpoint: `#rememberDiscoveryTree` reads the shared `#discoverySession` fresh, and by the time a late continuation resolves that is the next sweep's. `#isStaleDiscoveryContinuation()` compares the `discoveryEnumerationPass` epoch — an AsyncLocalStorage store, so it follows the async continuation and still carries the epoch that ISSUED the read — against the live one. It is the same fence the tree-read counters already used. Applied to the checkpoint write and to overload attribution, so a 429 that arrives after its sweep was abandoned cannot drive the replacement's ratchet. 3. The dispatch loop gets the same budget guard as the read loop, so a pass abandoned during enumeration cannot dispatch after its lease went back. 4. A lease claimed after the budget gave up on the claim was stranded: nobody would renew, commit or release it, so every later sweep deferred for a whole lease window. A compensating release is now attached to the abandoned claim. Fail-first verified by mechanism — with the compensation ablated the test fails with `stranded lease was never released`. 5. Unref'd deadline timers let Node exit before the budget fires. Under a one-shot `runOnce()` whose only pending work is a promise nothing else references, the command would return without reporting the wedge or releasing the lease. Both deadline timers are referenced now; they live for at most one budget and `dispose()` clears them from a `finally`. Also, on the review's reading of a comment: the pre-backfill watermark read is bounded neither by the sweep budget NOR by anything in the orchestrator — `#currentEventHighWatermark` (factory.ts:2192) awaits the mount directly under a bare try/catch. What bounds it in production is one layer lower, the deployed client's own `#bounded()` (relayfile-cloud-mount-client.ts:1057, #368). The comment now says which layer, because a `MountClient` without that deadline has no bound here at all. And the e2e must-fire no longer risks blaming a phase string for a timing stall: the budget has 400 ms of headroom over two in-memory calls, and `hungCalls` is asserted before the phase so a mis-timed run names the real cause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
|
@coderabbitai review Requested for exact head |
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Two findings at 2c2dd86, both this PR's own lesson recurring inside this PR. 1. THE BOUND BECAME THE WEDGE. `stop()` deliberately outlives the sweep it started (#301, the `#readinessReconcileAbandonedWait` drain), so a wedged sweep makes shutdown exactly as long as the sweep budget — 90 minutes at the default. Referencing the timer did not create that (before this PR the drain was unbounded, so shutdown was unbounded too), but it is the same trap the teardown deadline already answers one layer down, and an operator restarting a wedged container is the person who pays. NOT fixed by `unref()`. That is the trivially wrong version: it also lets Node exit before the budget fires, so a one-shot `runOnce()` returns having neither reported the wedge nor released the lease — the P2 that made the timer referenced in the first place. The two asks are in tension and only a shutdown-specific path satisfies both. `stop()` now arms a grace timer over the drain; after `STOP_TEARDOWN_TIMEOUT_MS` it calls `budget.expire()` on every in-flight sweep, routing them into the ordinary abort path — lease released, teardown bounded — instead of holding the process. The grace is what keeps an ordinary restart from discarding a sweep that was about to commit. A sweep that starts while `#stopping` is already set is expired immediately, so it cannot hand shutdown a fresh 90-minute budget. 2. The lease claim is now issued INSIDE the budget callback, so a spent budget rejects the phase without opening a lease it could only hand straight back. A lease taken after expiry makes every later sweep defer — the same "later cycles wait on a pass that is already over" failure this PR's own comparison names in `reconcileTimeoutMs`. Pairs, and their fail-first, verified by ablation: - must-fire: a live daemon whose PERIODIC sweep wedges under a 60 s budget still completes `stop()` inside 4 s. With the grace ablated it hangs to the vitest timeout — shutdown waiting out the budget, which is the defect. - must-not-fire: the budget timer appears in `process.getActiveResourcesInfo()` while a sweep runs and is gone the moment `dispose()` runs. This is what fails for the `unref()` version — that list contains only resources KEEPING THE EVENT LOOP ALIVE, so an unref'd timer never appears — and it also pins the other half: a settled sweep leaves nothing behind, which is what makes a referenced 90-minute timer affordable. - must-fire: a sweep aborted in the fleet-probe phase opens no lease and releases none. Scoped honestly in the test: every entry into `#claimDiscoverySweepUnderBudget` is preceded by a `budget.run` that throws first, so "already spent on entry" is a microtask race rather than a reachable state, and moving the claim inside the callback closes it by construction. The guarantee that does the work — `budget.run` never invokes its thunk once spent — is asserted directly on the primitive. - must-not-fire: a healthy sweep still claims exactly once and dispatches. The trivially wrong way to stop a spent budget claiming is to stop claiming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
|
@coderabbitai review Requested for exact head |
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Scope note before anyone merges this: it bounds the stall, it does not prevent itRecording this on the PR so the caveat travels with the change rather than living in a status thread. Measured against the deployed worker this morning,
The The container booted at 09:03:24Z, which is after Why that matters for this PR. The aggregate budget defaults to 90 minutes: What it does buy is recovery, not speed. On the deployed build the reconcile timeout rejects the caller's wait and leaves So: necessary, not sufficient. Please do not merge this believing it is the cure for the outage. The budget is also a config dial ( CI on this branch is currently red on |
Answers both open review threads on #374. `stop()` armed the sweep-budget grace timer only after awaiting `#heldAgentDeadlineSweepInFlight`, so an unrelated in-flight held-agent sweep silently extended a wedged discovery sweep's reprieve from `grace` to `held-agent sweep duration + grace` — unbounded if that sweep never returns, which is precisely the bound this change exists to provide. The grace is a timer; arming it costs nothing, so it now starts the clock at the moment shutdown starts, and the teardown it guards moved inside its `try` so the timer is still cleared on every path. Covered by a new must-fire that observes WHEN the lever arms rather than that it exists: a discovery sweep is wedged, a held-agent sweep is parked mid-release through the fleet, and the shutdown counter is read 3.2s into `stop()` — past the 2.5s grace, far short of the 60s budget. It fails `expected undefined to be 1` against the previous ordering. Also wraps the one test that arms the real 90-minute timer in `try/finally`. Without it a failing assertion left a *referenced* `Timeout` in the worker, so the failure would present as a hung suite instead of a named assertion. Proven with a temporary must-fire / must-not-fire pair: the old shape leaves the timer active after a throw, the new one does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 372b13bc-44a2-45a5-b5cc-aa228ccca39d
|
@coderabbitai review Requested for exact head |
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
This PR is an outage fix — but it is inert at its default valueFlagging this before the merge decision, because the framing matters. The outage. Factory's discovery sweep is wedged in production right now and has dispatched nothing for 39+ hours. Full measurements in #372, but in short: one sweep started Why this PR is the right mechanism. The budget expires from inside Why merging it alone would change nothing.
So the budget resolves to 90 minutes against a ~50-minute container lifetime and would never fire. Meanwhile Therefore: this is an outage fix only if it lands together with an explicit CI is currently red / |
… grace The new must-fire read the counter after a fixed 3.2s wait against a 2.5s grace — a few hundred milliseconds of headroom, which on a loaded worker is a new flake. This suite already carries two (#342, #373), and adding a third inside the PR whose subject is a wedge is the wrong trade. Polling costs the discrimination nothing: the held-agent sweep stays parked until the test releases it, so against the previous ordering `stop()` never reaches the arming call at all and the poll can only end in its own deadline. Re-measured both directions — pre-fix ordering: exit=1, "the shutdown lever never armed while an unrelated held-agent sweep was in flight"; with the fix: exit=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 372b13bc-44a2-45a5-b5cc-aa228ccca39d
|
@coderabbitai review Requested for exact head |
Corrections to my comment above, and a status changeFour things in my earlier comment need correcting. A lane verified my claims independently and disputed two of them; it was right, and I would rather correct them here than have anyone act on the originals. 1. CI is no longer red — this PR is green and merge-readyI said "CI is currently red / The earlier red at 2. My "set it near the 10-minute stall threshold" recommendation was risky — prefer ~15 minThis is the correction that matters most, because it is the one someone might have acted on.
Recommended instead: "liveSubscription": { "sweepBudgetMs": 900000 }
3.
|
… tree A sweep went silent for 11m53s and then reported stalled (inFlightMs 727833, missedPasses 12) while the process stayed healthy — liveHeartbeat logging throughout, log ring lossless, consecutiveFailures 0. Nothing was failing. The sweep was slow by construction, in `resolveIssuePrFromMount`, which answers "which PR belongs to this issue" by reading every mounted PR record one at a time. Four defects, all in the same walk. 1. `#resolveIssuePr` could not scope to a repository. `resolveIssuePrFromMount` has always honoured `opts.repo`, but `#resolveIssuePr`'s own opts had no `repo` field, so it forwarded `undefined` and walked EVERY configured repository — 21 in the live workspace — to find a PR that can only live in one. None of its four call sites nor the `probePrResolver` port could scope it. Adds `repo?: string` and threads the routing answer through all of them via `#probeRepoForIssue`, which reuses `dependencyRepoForIssue` — the same helper `#dependencyIsTerminalOrMerged` already uses for its own probe. Ambiguous routing still walks unscoped: narrowing on a guess would miss a PR that is really there. 2. The mount hit never populated the cache it reads. `#resolveIssuePr` reads `#probePrResolvedCache` at the top, then runs the mount walk FIRST — the common hit — and returned without ever writing it. The cache had a reader and no writer on the hot path, so the whole walk repeated per caller, per sweep, forever. Now cached on the same terms as the gh branch: same key, same TTL, same draft exclusion. 3. The walk read most pull requests twice. `githubPullRoots` returns two roots for one repository — the nested `<owner>/<repo>/pulls/` layout and the flat `<owner>__<repo>/pulls/by-id/` alias — and unions them into a Set keyed by PATH STRING, so one PR under two spellings counted twice. Deduped on the identity the path already carries via `githubPullPathParts`, which costs no read. Paths that carry no PR identity (`_index.json`, per-PR `comments/*`) are left in the walk and still read exactly as before. 4. The read loop was invisible. `listTree` is wrapped by `#listRelayfileTree` — named, timed, logged. The `readFile` per candidate ran in a bare try/catch that swallows failures into `undefined`, with no logger, counter or progress line, which is why twelve minutes of real work was indistinguishable from a hung process for three prior investigation layers. Adds progress reporting on the same cadence helper the ready-issue read loop uses, plus a `probePrMountReads` counter. Also, two things found while fixing the above: - The cache invalidation on completion deleted only the BARE issue key, never the `:open` / `:legacy` suffixed variants `#resolveIssuePr` actually writes. Every `openOnly` probe — i.e. the completion path — was never invalidated. Harmless while the mount branch wrote nothing; a live correctness bug the moment it does. Now clears the whole key family. - `#dependencyIsTerminalOrMerged` does not go through `#resolveIssuePr` (it must not fall back to gh), so it saw no cache at all, and `#terminalDependencyIdentities` memoises only the TRUE answer. A dependency that is not merged was re-walked in full for every issue declaring it, on every sweep. Adds a sweep-scoped memo for the negative answer, cleared beside the terminal set so a PR merging between sweeps is still observed. This is the path that produced the reported repro. Relationship to #374, which bounds the whole sweep: complementary, not redundant. #374 stops a wedge burning unbounded wall-clock — the seatbelt. This removes the reason the walk is expensive — the brakes. relayfile-adapters#271 would remove the walk entirely by putting `headRef` in the pull index row. NOT FIXED, deliberately: no early break on a maximal-score match. The sort is `b.score - a.score || b.prNumber - a.prNumber`, so a score-30 hit does not win until every higher-numbered candidate is known to score no better, and `readProbePrCandidate` takes `pr.number` from the payload rather than the path, so path order does not prove PR-number order. Semantics could not be shown preserved, so per the brief the dedupe ships and the early break does not. No index fast path either: `pulls/_index.json` rows carry no `headRef`, and the primary match (score 30) is a branch match, so the index cannot exclude any PR from consideration and a title hit (score 20) must never be returned while an unread branch match could outrank it. Instead the resolver now logs WHY it fell back — index absent, shape unrecognised, or present without `headRef` — so the day adapters#271 lands shows up in the logs rather than passing unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7 Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7 Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
… tree (#377) * fix(orchestrator): stop the probe PR resolver re-walking the whole PR tree A sweep went silent for 11m53s and then reported stalled (inFlightMs 727833, missedPasses 12) while the process stayed healthy — liveHeartbeat logging throughout, log ring lossless, consecutiveFailures 0. Nothing was failing. The sweep was slow by construction, in `resolveIssuePrFromMount`, which answers "which PR belongs to this issue" by reading every mounted PR record one at a time. Four defects, all in the same walk. 1. `#resolveIssuePr` could not scope to a repository. `resolveIssuePrFromMount` has always honoured `opts.repo`, but `#resolveIssuePr`'s own opts had no `repo` field, so it forwarded `undefined` and walked EVERY configured repository — 21 in the live workspace — to find a PR that can only live in one. None of its four call sites nor the `probePrResolver` port could scope it. Adds `repo?: string` and threads the routing answer through all of them via `#probeRepoForIssue`, which reuses `dependencyRepoForIssue` — the same helper `#dependencyIsTerminalOrMerged` already uses for its own probe. Ambiguous routing still walks unscoped: narrowing on a guess would miss a PR that is really there. 2. The mount hit never populated the cache it reads. `#resolveIssuePr` reads `#probePrResolvedCache` at the top, then runs the mount walk FIRST — the common hit — and returned without ever writing it. The cache had a reader and no writer on the hot path, so the whole walk repeated per caller, per sweep, forever. Now cached on the same terms as the gh branch: same key, same TTL, same draft exclusion. 3. The walk read most pull requests twice. `githubPullRoots` returns two roots for one repository — the nested `<owner>/<repo>/pulls/` layout and the flat `<owner>__<repo>/pulls/by-id/` alias — and unions them into a Set keyed by PATH STRING, so one PR under two spellings counted twice. Deduped on the identity the path already carries via `githubPullPathParts`, which costs no read. Paths that carry no PR identity (`_index.json`, per-PR `comments/*`) are left in the walk and still read exactly as before. 4. The read loop was invisible. `listTree` is wrapped by `#listRelayfileTree` — named, timed, logged. The `readFile` per candidate ran in a bare try/catch that swallows failures into `undefined`, with no logger, counter or progress line, which is why twelve minutes of real work was indistinguishable from a hung process for three prior investigation layers. Adds progress reporting on the same cadence helper the ready-issue read loop uses, plus a `probePrMountReads` counter. Also, two things found while fixing the above: - The cache invalidation on completion deleted only the BARE issue key, never the `:open` / `:legacy` suffixed variants `#resolveIssuePr` actually writes. Every `openOnly` probe — i.e. the completion path — was never invalidated. Harmless while the mount branch wrote nothing; a live correctness bug the moment it does. Now clears the whole key family. - `#dependencyIsTerminalOrMerged` does not go through `#resolveIssuePr` (it must not fall back to gh), so it saw no cache at all, and `#terminalDependencyIdentities` memoises only the TRUE answer. A dependency that is not merged was re-walked in full for every issue declaring it, on every sweep. Adds a sweep-scoped memo for the negative answer, cleared beside the terminal set so a PR merging between sweeps is still observed. This is the path that produced the reported repro. Relationship to #374, which bounds the whole sweep: complementary, not redundant. #374 stops a wedge burning unbounded wall-clock — the seatbelt. This removes the reason the walk is expensive — the brakes. relayfile-adapters#271 would remove the walk entirely by putting `headRef` in the pull index row. NOT FIXED, deliberately: no early break on a maximal-score match. The sort is `b.score - a.score || b.prNumber - a.prNumber`, so a score-30 hit does not win until every higher-numbered candidate is known to score no better, and `readProbePrCandidate` takes `pr.number` from the payload rather than the path, so path order does not prove PR-number order. Semantics could not be shown preserved, so per the brief the dedupe ships and the early break does not. No index fast path either: `pulls/_index.json` rows carry no `headRef`, and the primary match (score 30) is a branch match, so the index cannot exclude any PR from consideration and a title hit (score 20) must never be returned while an unread branch match could outrank it. Instead the resolver now logs WHY it fell back — index absent, shape unrecognised, or present without `headRef` — so the day adapters#271 lands shows up in the logs rather than passing unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7 Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7 Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7 * fix(orchestrator): never scope a probe to repos.default, and key the probe cache by repo Two #377 review findings from cubic-dev-ai. Both were correct; the first was a correctness regression this PR introduced. P1 — `#probeRepoForIssue` scoped every probe to `repos.default` whenever the issue carried no label or project evidence. Routing precedence is byLabel, byProject, keywordRules, default, and `dependencyRepoForIssue` can see neither the triage decision nor `keywordRules` — those match on issue TEXT through triage. So for a keyword-routed issue it answered `repos.default` while dispatch had opened the PR in the keyword-selected repository. The probe then walked one repository, confidently, and found nothing: "no PR" reported for an issue that has one, and the completion path acts on that answer. That is strictly worse than the slow walk this PR set out to remove, and it contradicted the docstring sitting two lines above it. Threading the real triage decision was not reachable: all five probe call sites take only a `LinearIssue`, and at completion time the decision no longer exists. So the fallback is now the unscoped walk — `dependencyRepoForIssue` grows an opt-out `allowDefault` (default unchanged for its four other callers) and the probe wrapper passes `false`. Ambiguity widens the walk; it never narrows it. The dedupe and cache in this same PR already blunt the cost. P2 — `repo` narrows which pull requests a resolution can even see, so it is a resolution dimension, but it was absent from the cache and gh-backoff keys. A route change could therefore serve the previous repository's PR, and the completion path probes and CLOSES what it is handed. Adding that dimension exposed a second, pre-existing defect: the completion sweep wrote its draft-PR backoff under a BARE issue state key while `#completionPrForIssue` read the suffixed one. They agreed only by accident, and the new suffix broke that accident — caught by `gh PR fallback skips draft PRs and backs off repeated unresolved lookups`, which went red. Both maps now build their key through one shared `#probePrCacheKey`, so the two writers cannot drift again. Every dimension stays a trailing `:`-prefixed segment, so the completion invalidation added in this PR keeps clearing the whole key family. NOT SHIPPED: a test for P2's stale cross-repo hit. Probe scope is a pure function of (issue, config) at all five call sites, and the completion path clears the whole key family, so no public path varies the scope for one issue inside the TTL. Every way to force it needed a production test hook, and this file has no precedent for reaching into internals. P2 ships as defensive correctness plus the real backoff-key fix its test DID catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
…e cadence that cannot
THIS COMMIT DOES NOT ADD A BOUND. It publishes the ones that already exist,
because their absence from the health stanza has now been read twice as their
absence from the code — including in the brief that asked for this fix.
WHAT THE STANZA SAID. A wedged 0.1.76 published:
"readinessReconcile": {
"state": "healthy", "consecutiveFailures": 0,
"failureThreshold": 3, "inFlightMs": 268232, "intervalMs": 60000
}
`intervalMs` is a scheduler tick and cannot preempt anything. Next to an
`inFlightMs` climbing 1:1 with wall clock it is indistinguishable from an
unbounded hang, and there was no field that could tell the two apart. The
reading taken from it — "there is no timeoutMs, so nothing bounds this" — is
false, and it is the reading this stanza invites.
WHAT IS ACTUALLY BOUNDING THAT PASS. Three deadlines, all live on this path in
0.1.76: `relayfileOperationTimeoutMs` per call (#351/#368),
`readinessReconcileTimeoutMs` on the caller's wait (#296), and the aggregate
`sweepBudgetMs` from #374 — `#reconcileReadyIssues` -> `#runOnceWithReadinessDeadline`
-> `runOnce()` -> `#runOnceWithDiscoveryFence` -> `startDiscoverySweepBudget`.
`readinessReconcile` IS the discovery sweep's health stanza; sweep-budget.ts
names it as such. The pass was bounded. It was bounded at 90 minutes, because
`sweepBudgetMs` derives from `reconcileTimeoutMs`, so at 268 s it had 89
minutes left to run and no field said so.
Two numbers now ship: `timeoutMs` (ends the wait) and `sweepBudgetMs` (unwinds
the sweep and hands the lease back). The second is the one that answers "when
does this recover", which is the question every reader of this stanza has
actually been asking.
`missedPasses` also moves onto the heartbeat record. It already existed on the
public projection (#295/#300) and was absent from the heartbeat stanza — which
is the surface an operator opens first, and the one every report so far has
quoted.
NOT A REPORTING BUG, AND DELIBERATELY NOT CHANGED. `state: "healthy"` at
268 s is correct. `derivedReadinessReconcileState` re-derives `stalled` from
`inFlightMs > intervalMs * READINESS_RECONCILE_STALL_INTERVALS`, and that
constant is 10 — so the flip was due at 600 s and the observation window
(14:37Z-14:41Z) closed 5.5 minutes early. Lowering it is the trivially wrong
fix: public-health.ts documents #36's 61-minute post-boot hydration as the
reason a small multiple cries wolf on every cold container.
TESTS, both against the real production numbers:
- must-fire: a heartbeat carrying the bounds publishes both, and reports
missedPasses 4 for the exact 268232/60000 pass above. Fail-first verified by
ablation — with only public-health.ts and types.ts reverted it fails
`expected undefined to be 5400000`.
- must-not-fire: a recorded `0` or negative bound is dropped rather than
republished as an instant deadline, and an instance predating the fields
still projects `healthy` with both absent. This one passes before and after
by construction: it is the guard on the trivially wrong version, not a
demonstration of the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
…e sweep bounds that already exist (#379) * fix(health): publish the bounds that can preempt a sweep, not just the cadence that cannot THIS COMMIT DOES NOT ADD A BOUND. It publishes the ones that already exist, because their absence from the health stanza has now been read twice as their absence from the code — including in the brief that asked for this fix. WHAT THE STANZA SAID. A wedged 0.1.76 published: "readinessReconcile": { "state": "healthy", "consecutiveFailures": 0, "failureThreshold": 3, "inFlightMs": 268232, "intervalMs": 60000 } `intervalMs` is a scheduler tick and cannot preempt anything. Next to an `inFlightMs` climbing 1:1 with wall clock it is indistinguishable from an unbounded hang, and there was no field that could tell the two apart. The reading taken from it — "there is no timeoutMs, so nothing bounds this" — is false, and it is the reading this stanza invites. WHAT IS ACTUALLY BOUNDING THAT PASS. Three deadlines, all live on this path in 0.1.76: `relayfileOperationTimeoutMs` per call (#351/#368), `readinessReconcileTimeoutMs` on the caller's wait (#296), and the aggregate `sweepBudgetMs` from #374 — `#reconcileReadyIssues` -> `#runOnceWithReadinessDeadline` -> `runOnce()` -> `#runOnceWithDiscoveryFence` -> `startDiscoverySweepBudget`. `readinessReconcile` IS the discovery sweep's health stanza; sweep-budget.ts names it as such. The pass was bounded. It was bounded at 90 minutes, because `sweepBudgetMs` derives from `reconcileTimeoutMs`, so at 268 s it had 89 minutes left to run and no field said so. Two numbers now ship: `timeoutMs` (ends the wait) and `sweepBudgetMs` (unwinds the sweep and hands the lease back). The second is the one that answers "when does this recover", which is the question every reader of this stanza has actually been asking. `missedPasses` also moves onto the heartbeat record. It already existed on the public projection (#295/#300) and was absent from the heartbeat stanza — which is the surface an operator opens first, and the one every report so far has quoted. NOT A REPORTING BUG, AND DELIBERATELY NOT CHANGED. `state: "healthy"` at 268 s is correct. `derivedReadinessReconcileState` re-derives `stalled` from `inFlightMs > intervalMs * READINESS_RECONCILE_STALL_INTERVALS`, and that constant is 10 — so the flip was due at 600 s and the observation window (14:37Z-14:41Z) closed 5.5 minutes early. Lowering it is the trivially wrong fix: public-health.ts documents #36's 61-minute post-boot hydration as the reason a small multiple cries wolf on every cold container. TESTS, both against the real production numbers: - must-fire: a heartbeat carrying the bounds publishes both, and reports missedPasses 4 for the exact 268232/60000 pass above. Fail-first verified by ablation — with only public-health.ts and types.ts reverted it fails `expected undefined to be 5400000`. - must-not-fire: a recorded `0` or negative bound is dropped rather than republished as an instant deadline, and an instance predating the fields still projects `healthy` with both absent. This one passes before and after by construction: it is the guard on the trivially wrong version, not a demonstration of the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7 Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7 * fix(orchestrator): bound the completion release retry, which is what was actually spinning The `no pid available to terminate ... during completion` lines repeating 15+ times in one evidence payload are a CO-SYMPTOM, not the cause. Fixing the PID classification would have changed nothing, and this commit explains why before it changes anything. WHY THE MISSING PID IS NOT THE LOOP. `#releaseAndTerminateAgents` logs that line when `#terminationRoots` returns `{ pids: [], status: 'unresolved' }`, then falls through. Nothing on that branch reaches `failed[]` — only a throw from `#fleet.release()` that is not `isAgentAlreadyGoneOnRelease` does. So the three agents were re-attempted because their RELEASE kept failing, and the no-PID line was printed once per agent per attempt on the way past. WHY THE LOOP NEVER ENDED. `#finishDurableRelease` does not throw on a failed release: it returns `false` and calls `#scheduleReleaseRetry`, which re-arms at `DISPATCH_LIFECYCLE_RETRY_MS` — 1 000 ms, unbounded. Every re-arm therefore arrives on the RESOLVED path, which is why the `.catch()` in both schedulers never bounded it and why a bound written there would have been a fix that never fired. The budget is charged at the scheduling point instead. WHAT A PASS COSTS, WHICH IS WHY 1 Hz FOREVER IS NOT FREE. Each pass calls `#terminationRoots` once per agent inside the release AND once per agent again inside `#writeInFlightRegistry` — a process-table scan each — plus a durable lifecycle read and write. For the three agents in the report that is order ten scans and several state operations per second, indefinitely. #303 already measured this exact shape once, at 1477 state GETs in 111 s, and bounded the RATE of the capacity-wait re-arm in response. It deliberately left the COUNT unbounded there, because waiting for capacity is legitimate. DESIGN CHOICE: (a) BOUNDED RETRIES, NOT (b) RECLASSIFY NO-PID. Not chosen under uncertainty — the code already tells the two cases apart, and it says (b) is wrong. `#terminationRoots` returns `'missing'` for confirmed-gone (a remote placement, or a process scan that came back missing AND a resolver that agreed) and `'unresolved'` for could-not-determine (no resolver and no recorded pids, an AMBIGUOUS scan, a resolver that returned nothing, or one that threw). The error only fires on `'unresolved'`. Treating that as already-terminated would mean skipping termination of a process that may well be alive — an ambiguous scan is literally "more than one candidate matched" — leaving orphans holding worktrees and slots. And it would not have stopped the spin regardless, per the first section. Release is also the opposite shape from #303's capacity wait, which is what makes bounding the count right here and wrong there: it is the last step of a work unit that is already finished — issue closed, writeback acknowledged, batch slot returned — so a release that has failed ten times is not waiting for anything. Ten attempts at the 1 s floor is ~10 s of genuine retry, which covers a control-plane blip or a lease handover and does not cover a permanent failure. SCOPED SO IT CANNOT ABANDON WORK THAT WAS NEVER FAILING: - Only release re-arms spend the budget. `#scheduleDispatchLifecycleRetry` takes an explicit `releaseAttempt` flag, so a `DispatchLifecycleCapacityError` or `DispatchLifecycleOwnedElsewhereError` — both legitimate waits on someone else — still retries forever, exactly as #303 intended. - Progress refunds the budget, so ten bounds CONSECUTIVE no-progress passes rather than capping a slow multi-agent release. This terminates: an agent released once is checkpointed and skipped next pass, so the remaining set strictly shrinks and a refund can only be earned finitely often. - The durable lifecycle is RETAINED on exhaustion. A takeover or a restart re-drives it from the persisted phase. This bounds one process's spin; it does not declare the work unit clean. - Keyed by `dispatchLifecycleKey`, so the budget follows the work unit rather than an agent, a surface or a dispatcher — the AR-448 identity rule. Exhaustion is logged at `error`, not `warn`, and increments `dispatchLifecycleReleaseAbandoned`. Every layer of this failure so far has been invisible until somebody read stderr by hand, and a work unit whose cleanup this process has permanently given up on must not be inferable only from the absence of further log lines. TESTS (3), against a fleet that reproduces the production shape exactly — `release()` throws for `issue-done` and `resolveAgentPid` returns `'unresolved'`, so the same no-PID line is emitted on every pass: - must-fire: the dead-letter counter reaches 1, the exhaustion error is logged, and three further seconds of wall clock buy no additional release attempts. Fail-first verified by ablation: with factory.ts reverted it fails after 40 543 ms with `expected undefined to be 1` — the wait can only end in its own deadline, because the loop re-arms for as long as the process lives. That is a property of the loop, not of any number chosen in the test. - must-not-fire: a release that succeeds still completes the work unit and releases each agent exactly once, with the counter unset. The trivially wrong way to stop a retry loop is to stop retrying. - must-not-fire: a release that fails several times and then succeeds still completes, with the counter unset — the transient case the retry exists for. Both must-not-fires passed under the ablation too, which is what makes them guards rather than restatements of the fix. RETRY CADENCE IS NOW AN INJECTABLE PORT, and that is a test-stability fix in its own right rather than a convenience. Exhausting a ten-attempt budget at the real 1 s floor costs ten real seconds per case; the first version of this suite did exactly that and added 41 s to `factory.test.ts`. Run beside two other files it pushed an already-300 s combination over an edge and four UNRELATED tests began failing on timing — the reopen-fence and Slack-reply-route cases — while the same three files passed on `origin/main` and `factory.test.ts` alone passed 631/631 on the branch. Buying a fourth flake in this suite (it already carries #342 and #373) to test a fix for a spin is the wrong trade. `dispatchLifecycleRetryMs` follows the existing convention for exactly this — `babysitterWakeUnreachableRetryMs`, `babysitterWakeUnreachableEscalateMs`, `startupAgentExitDrainTimeoutMs` are all test-only port overrides of a built-in timing. Only the delay between attempts moves; the BUDGET under test is the real one. Overhead is now +4 s, the four unrelated failures are gone (709/709 on the same three files), and the ablation still fails with `expected undefined to be 1` — unambiguously, because `dispatchLifecycleReleaseAbandoned` does not exist on `origin/main` at any cadence. The transient case sets a failure count on the fake rather than flipping a flag from the test body, so it cannot race the cadence it runs under. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7 Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7 * fix(orchestrator): make the release bound actually fire on the durable path Answers three P1 findings on #379. The first is the important one: the bound as first written DID NOT FIRE in production, and the review caught it. 1. THE BUDGET RESET ON THE PATH THAT MATTERS, SO THE BOUND NEVER FIRED. `#driveDispatchLifecycle` discards `#finishDurableRelease`'s boolean in its `phase === 'releasing'` branch, and that method returns `false` rather than throwing on a failed release. So a FAILED release makes the drive RESOLVE, and the scheduler's success handler ran on every re-arm — where it called `#clearReleaseAttempts`. The counter was zeroed once per pass and could never reach the cap. This is the same never-fires shape the first version of this commit correctly rejected in the `.catch()`, moved one layer over into the `.then()`. Diagnosing the resolved path as the live one and then putting the refund on it was the error. The refund is removed from the scheduler entirely. It now happens only where success is actually known: `#finishDurableRelease` clears the budget on real per-agent progress and again when the work unit completes. WHY THE ORIGINAL TESTS MISSED IT. `#usesDurableDispatchLifecycle()` is `durableOwnership ?? placementLocality === 'remote'`, and `FakeFleetClient` places locally, so all three original cases exercised `#scheduleReleaseRetry`'s own timer — which has no success handler and therefore no reset. The deployed Factory places remotely. The suite proved a property of the path production does not take. New must-fire on the DURABLE path (`RemoteLifecycleFleetClient` + `InMemoryStateStore`), asserting the counter SURVIVES ACROSS RE-ARMS rather than that a dead-letter is reachable by some path. Fail-first verified by ablation: restore the `#clearReleaseAttempts(key)` line and only that case fails, `expected undefined to be 1` after 10 125 ms, while the three local cases still pass — which is what pins the discrimination to the durable path. 2. THE WRONG BUDGET WAS CHARGED. The generic arm of the drive's `.catch()` re-arms for dispatch, publishing and recovery failures as well as releases, and it charged all of them. That would dead-letter a work unit that was never stuck in a release loop. Charging is now confined to `#scheduleReleaseRetry`, whose every caller is a release failure: the three inside `#finishDurableRelease`, and `#completeIssue`'s catch once `releaseReasonForRetry` is set. The generic re-arm passes no charge at all. Pinned by a call-site audit rather than by a behavioural test, and that is deliberate. I could not reach that arm from a realistic fixture — forcing durable lifecycle reads to throw makes the agent-exit handler fail before any lifecycle retry is scheduled, so a test built that way passes whether or not the narrowing is present. Confirmed by ablation: with `releaseAttempt = true` restored, the fixture-based version still passed, and instrumenting it showed zero `durable dispatch lifecycle retry failed` warnings — the branch was never entered. Shipping that would have been a test that proves nothing, so the audit states the structure instead. Known gap, stated plainly: a release failure that THREW out of `#finishDurableRelease` would reach the generic arm and re-arm unbounded. Every failure path in that method returns `false` and schedules its own retry, so this is not a reachable shape today, and if one appears it degrades to the pre-existing unbounded behaviour rather than to a wrong dead-letter. 3. THE DEAD-LETTER LEAKED THE SLOT. Trading an unbounded 1 Hz spin for a permanently leaked in-flight record is not obviously the better failure: a spin is loud and self-describing, while a leaked slot silently reduces dispatch capacity until the process is restarted. Local completion never calls `batch.complete`, so exhaustion left the work unit in flight forever. `#releaseDeadLetteredSlot` now hands the batch slot back, drops any uncompensated claim, rewrites the in-flight registry, and admits whatever was queued behind it — a freed slot nothing is admitted into is only half the repair. The durable lifecycle is still deliberately RETAINED in `releasing`, so a successor or restart re-drives the same cleanup with a fresh budget; freeing a process-local slot is not a terminal phase and does not declare the work clean. The work unit therefore ends up recoverable, never merely abandoned. Must-fire asserts the slot is released after exhaustion. Fail-first by ablation: stub the call out and it fails with the work unit still in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
Do not merge — factory-lead holds the gate.
Three unbounded calls have wedged this sweep in a single day, on three different
transports. Each was real, each was bounded, and each time the wedge came back
one layer down. This PR does not bound a fourth. It makes the class survivable.
A. The aggregate budget
The property: no sweep can be in flight for longer than its budget, whatever
it is waiting on. Elapsed time is charged against one timer for the whole
pass, so it does not matter which await is slow, how many there are, or how
many times the sweep retries one of them.
Why a per-call bound cannot do this
relayfileOperationTimeoutMs(#351/#368)reconcileTimeoutMs(#296)runOnce()runOnce(), the#runOnceInFlightbranch, mainfactory.ts:2986-2989)sweepBudgetMs(this PR)#runOnceWithDiscoveryFenceExpiring from inside the fence is the entire difference. The sweep unwinds,
the discovery lease goes back,
#runOnceInFlightclears, and the next cycleclaims a fresh lease and runs clean.
Mechanism, plainly — what it can and cannot interrupt
budget.run()is a race, not a cancellation. Same honest limitation #368documented for
withRelayfileCallDeadline, stated for the same reason.loop keeps running, and a side effect already in flight still lands.
budget.signalaborts at expiry, so anything that honours anAbortSignalis really cancelled. Nothing in the sweep consumes it yet: therelayfile client mints its own per-call signal and that file is owned by
factory-wedge-layer2-0825this week. It is exported so wiring it is one line,not a redesign.
assertNotExpired()is a between-await check and is worth nothing againsta call that never returns. What it buys is that an already-abandoned pass
unwinds at its next loop iteration if it ever regains control, instead of
running to completion beside the sweep that replaced it.
Teardown is bounded separately, on purpose
On the path that matters the budget is spent by construction, so teardown cannot
run under it — every step would reject and the lease would never be released,
and releasing it is the half that makes the next cycle clean. An unbounded
release would re-create this wedge one layer down, which is the pattern this
PR exists to end. It gets its own 30 s deadline. An abandoned release costs an
orphaned lease for one expiry window, which a later sweep reclaims as an orphan
(
claim.reclaimedLease).The default deliberately changes no timing
sweepBudgetMsdefaults toreconcileTimeoutMs(90 min) and is clamped to it,so no sweep that survives today is killed by this. The number is a policy
dial; the mechanism is the deliverable. Tightening it has a real cost: the
checkpoint commits only at the end of the pass, so a budget below realistic
cold-mirror hydration (#36 measured 61 min in production) turns a slow boot into
a loop that never makes progress — the trap
reconcileTimeoutMsalreadydocuments. Recovery inside 90 minutes therefore needs either a tighter
sweepBudgetMsat deploy time (your call, one config key) or the L3 retry boundthe other lane owns.
B. Is discovery hostage to the sweep?
The coupling you named is necessary, and it is not what cost us dispatch. A
different one is, and this PR removes it.
discoveryDeferred: 'sweep-in-flight'(mainfactory.ts:3071) — necessary, keep itIt fires only when
claimDiscoverySweepfinds another owner holding thedurable lease. What the lease protects is the discovery checkpoint: two
concurrent passes would both advance the same cursor via
#finalizeDiscoveryCheckpoint/completeDiscoverySweep, so one would commit awatermark covering trees the other listed and the uncovered trees would never be
re-read. That is a correctness invariant, not a convenience.
And it is cheap. A deferred pass returns immediately (main
:3062-3072) — itdoes not block, it settles successfully, and it costs one interval of freshness.
Note it never fired during this incident: within one process
runOnce()coalesces rather than defers (main
:2986-2989). Which answers your otherquestion —
Why
discoveryDeferredwent"sweep-in-flight"→Nonebetween 0.1.74 and 0.1.75discoveryDeferredis a latched marker on the last SETTLED pass. It iswritten only by
#recordReadinessSweepOutcome, which runs only on a pass thatsucceeded, and cleared only on the failure path (main
factory.ts:2310).exist and
reconcileTimeoutMsis 90 min. So an early boot-time deferral(previous incarnation's lease still live inside its 5-minute window) latched
and froze on the surface for the whole outage.
:2310clears the marker every time.So the change is a symptom of L2 working, not new behaviour. The operational
lesson is the one that matters:
discoveryDeferredis not evidence thatdiscovery was being deferred at the moment you read it. It can be arbitrarily
stale. The same latch applies to
lastError(see the note at the end).The coupling that actually cost all dispatch — main
factory.ts:1983/:2021/:2039/:2366Discovery is not only the sweep. The live subscription drain is a full,
independent dispatch path that never touches the discovery lease:
#enqueueLiveEvent(:2358) →#scheduleLiveEventDrain(:2366) →#handleLiveEventsWithYield→#handlePreparedLiveChange→#handleChange→triageIssue(:7837) →dispatch(:7847).Its gate at
:2366checks#liveEventDrainScheduled,#liveEventDrainActive,#deferLiveEventDrainand#started. There is no sweep gate. It dispatcheshappily while a sweep is in flight.
Except for one thing:
:2039is inside afinallyaround an unboundedrunOnce(). A wedgedstartup backfill therefore means
start()never returns,#deferLiveEventDrainstays
trueforever, and the live drain never starts. Both discovery pathsdie together. That is why a wedged sweep meant zero dispatch instead of stale
dispatch.
This PR fixes exactly that, with no extra change.
runOnce()now alwayssettles, so
:2039always runs, so the live drain always starts and keepsdispatching while later sweeps are degraded. A slow sweep now costs freshness —
the outcome you asked for in B — because the durable safety net degrades while
the event-driven path stays up.
This is not a reading, it is a test.
must-fire: a live daemon whose sweep is wedged still shuts downstarts a real daemon whose first post-claim call neverreturns. Against
origin/main'sfactory.tsit fails after 4044 ms withstart never returned—start()itself never comes back, which is:2039never running. With the budget,
start()returns andstop()completes.I have not removed the
:3071deferral, per your instruction, and I wouldnot: the lease is load-bearing for checkpoint correctness.
Proof
Fail-first verified by mechanism, not by assertion colour: with only
factory.tsreverted the end-to-end must-fire fails after 4038 ms withsweep never settled— the sweep never settles, which is the production defectin the production shape. (The must-not-fire passes with the fix reverted, as it
must: a healthy sweep is unaffected either way.)
must-fire (end to end). A sweep whose first post-claim call never returns is
aborted at its budget with the abandoned phase named; the lease handback is
observed on the store, not inferred; the next cycle runs a fresh sweep and
dispatches.
must-fire (primitive).
sweep is out of time, not because it is slow. This is the aggregate
property no per-call deadline has.
the budget after more than one attempt. That is the L3 shape.
the dependency it just gave up on.
must-not-fire.
unbounded control —
pulled,dispatched,skipped, and the spawn list.Without this the trivial wrong fix (abort everything immediately) passes.
expiry.
sweepBudgetMs: 0the same hung call stays pending, so everyrejection above is attributable to the budget and not to the wrapper.
What this does NOT cover
wedged dependency still costs one whole budget per cycle.
unsticks. It cannot commit a checkpoint (the store's epoch guard) but its
in-flight side effects still land.
start()'s own pre-backfill watermark read is outside this budget, andoutside the orchestrator's bounds entirely.
#startLiveSubscriptioncalls#currentEventHighWatermark()(factory.ts:2192) before the backfill; itawaits
mount.getEventHighWatermark()under a bare try/catch, NOT through#withRelayfileOperation, sorelayfileOperationTimeoutMsdoes not reach it.What bounds it in production is one layer lower — the deployed client's own
#bounded()(relayfile-cloud-mount-client.ts:1057, fix(mount): bound the relayfile change feed so a hung tail read cannot wedge the readiness sweep #368). AMountClientwithout that deadline has none here at all, and
start()hangs forever.Found while writing the shutdown test; not fixed here because the file that
would fix it belongs to the L2 lane this week. (Thanks to the review for
catching that my first wording credited the orchestrator with a bound it does
not have.)
the budget — but a sweep whose teardown is ALSO abandoned still leaves an
orphaned lease for one expiry window.
#runOnceWithReadinessDeadlineabandoned-wait bookkeeping is unchanged;a budget expiry reaches it as an ordinary sweep failure. The seven
bounded readiness reconciliationtests that cover thataccounting now pass
sweepBudgetMs: 0— at the budget's default there is noabandoned-but-still-running sweep left for them to observe, which is the fix;
0selects the backstop underneath it, the same control idiom fix(mount): bound the relayfile change feed so a hung tail read cannot wedge the readiness sweep #368 used.Review fixes at this head
Every finding below was valid and is fixed, each with its own must-fire /
must-not-fire:
sweepBudgetMsdefault rejects any config that already tightenedreconcileTimeoutMs, and silently caps one that loosened it.transform(), never from a constant (resolvedSweepBudgetMs)#performRunOncecan write a stale tree into the REPLACEMENT sweep's checkpoint#isStaleDiscoveryContinuation()compares thediscoveryEnumerationPassALS epoch — which follows the async continuation, so it carries the epoch that ISSUED the read — against the live one; applied to the checkpoint write and to overload attributionrunOnce()returns without reporting the wedge or releasing the leasedispose()clears them from afinallyon every pathThe first one was the dangerous one: it would have taken Factory down on
deploy for any config that had tuned
reconcileTimeoutMs, because the schemathrows before the daemon starts.
A second round found two more, both this PR's own lesson recurring inside it:
stop()outlives the sweep it started (#301), so a wedged sweep made shutdown as long as the budget — 90 min at the defaultstop()arms a grace timer over the drain and then callsbudget.expire(), routing the sweep into its ORDINARY abort path. NOTunref(), which is in direct tension with the P2 above: it would also let Node exit before the budget firesbudget.runcould reject a spent budgetOn shutdown: before this PR that same drain was unbounded, so shutdown on a
wedged sweep never returned at all. A sweep that starts while
#stoppingisalready set is expired on creation, so it cannot hand shutdown a fresh budget.
The claim-ordering finding is scoped honestly in its test: "already spent on
entry" is not reachable by construction — every path into the helper is preceded
by a
budget.runthat throws first, and I verified by ablation that theintegration test still passes without the fix. What makes the microtask gap
worth closing is new in the same commit:
stop()can now spend a budgetasynchronously.
On the /evidence document — corrections
Three of the four readings do not survive the code, and none of them weakens the
case for this PR:
lastErroris stale, and does not describe the wedged pass.#readinessReconcileLastErroris cleared only on a successful pass (mainfactory.ts:2265). The wedged pass has not settled, so that breaker messagebelongs to the pass that failed at 07:51:59, not the one in flight since
07:52:59. Same latch as
discoveryDeferred.lastDurationMs: 4368is a failure's duration, not a success's. It iswritten on both paths (
:2304on failure).lastFailureAtMs07:51:59 is2 ms after
fleetControlPlane.lastFailureAtMs, so 4368 ms is how long thepass took to fail via the breaker. The last success was at 07:49:49. The
underlying instinct is still right — 07:47–07:50 shows sub-interval sweeps —
but this field is not the evidence for it.
the same event: the reconcile interval (
intervalMs: 60000) from the failureat 07:51:59.211, and the breaker's
resetTimeoutMs: 60000from07:51:59.209. They expire together by construction. Not a lead.
probe()wrapsroster()in
withTimeout(src/fleet/control-plane-circuit.ts:245-269), a genuinerace. So the 38-minute hang is almost certainly not in
roster().The unbounded thing on that path is the mutation after the probe
(
:180-183: "applies the local roster deadline without imposing a timeout onmutations") — a
spawn/resumeduring dispatch has no deadline at all. Thatis a live L4 candidate. I have not chased it, per the brief. Under this
PR it is a degraded sweep rather than the end of dispatch, which is the point.
Your framing stands where it counts: the failing transport is not the same one
twice, and an aggregate budget is agnostic to which one it is.